1. GIL이란?
GIL(Global Interpreter Lock)은 CPython 인터프리터 내부 상태를 보호하기 위한 전역 잠금 장치다.
전역 잠금 장치? 우선 이 말을 이해하기 전에 1991년 당시 초기 파이썬의 설계를 짚고 넘어가자. 당시 귀도 반 로섬은 파이썬의 메모리 관리 방식으로 레퍼런스 카운팅을 선택했다. 멀티스레드를 지원하되, 레퍼런스 카운팅의 안전성을 가장 단순하게 확보하는 방법으로 GIL을 택한 것이다.
CPython은 프로그램을 실행할 때 컴파일된 바이트코드를 스택 기반 인터프리터로 실행하며, 프로세스 실행 동안 일관성 있게 상태를 유지하기 위해 전역 인터프리터 락으로 일관성을 강제한다.
GIL이 멀티스레드 환경에서 안전하게 프로그램을 실행하기 위해 고안된 방법이란 건 이전 내용에서 말했지만 경험 상 고민 없이 작성한 파이썬 멀티스레드 코드에선 race condition이 발생했다. GIL이 제 역할을 하지 못하는 걸까? 여기서 중요한 건 GIL이 무엇을 보호하는가다.
| 보호 대상 | GIL이 보호하는가? |
|---|---|
| CPython 인터프리터 내부 (ob_refcnt 등) | ✅ 보호함 |
| 개발자가 작성한 공유 변수 (counter, list 등) | ❌ 보호 안 함 |
2. GIL이 실제로 보호하는 것 — 레퍼런스 카운팅과 ob_refcnt
GIL이 개발자의 공유 변수를 보호하지 않으면 도대체 무엇을 보호하는 걸까?
답은 CPython의 메모리 관리 메커니즘, 구체적으로는 레퍼런스 카운터(ob_refcnt) 에 있다.
CPython에서 모든 파이썬 객체는 내부적으로 다음과 같은 C 구조체로 표현된다.
ctypedef struct _object { Py_ssize_t ob_refcnt; // ← GIL이 보호하는 값 PyTypeObject *ob_type; } PyObject;
ob_refcnt는 해당 객체를 현재 몇 군데에서 참조하고 있는지를 나타내는 카운터다. 이 값이 0이 되면 더 이상 아무도 이 객체를 사용하지 않는다는 뜻으로 CPython은 즉시 메모리에서 해제한다.
이 개념을 직관적으로 이해하려면 도서관을 떠올려보자. 파이썬 객체는 도서관의 책이고 ob_refcnt는 그 책의 대출자 수다. 대출자가 한 명이라도 있으면 책을 폐기할 수 없고 대출자가 0명이 되는 순간 해당 책은 폐기 대상이 된다.
pythona = [1, 2, 3] # ob_refcnt = 1 (a가 참조) b = a # ob_refcnt = 2 (a와 b 둘 다 같은 객체를 가리킴) del b # ob_refcnt = 1 del a # ob_refcnt = 0 → 메모리 즉시 해제
여기서 b = a는 리스트를 복사하는 게 아니라는 점이 중요하다. 메모리에 [1, 2, 3]은 단 하나만 존재하고, a와 b 둘 다 그 하나를 가리킨다. ob_refcnt가 2인 이유가 바로 이것이다.
markdown메모리 ┌─────────────┐ │ [1, 2, 3] │ ← ob_refcnt = 2 └─────────────┘ ▲ ▲ │ │ a b (둘 다 같은 곳을 가리킴)
실제로 sys.getrefcount()를 사용해 각 단계별 참조 카운트를 확인해 볼 수 있다.
pythonimport sys a = [1, 2, 3] print(sys.getrefcount(a)) # 2 (a 자신 + 함수 인자 임시 참조) b = a print(sys.getrefcount(a)) # 3 (a + b + 함수 인자 임시 참조) print(a is b) # True (같은 객체) del b print(sys.getrefcount(a)) # 2 (a + 함수 인자 임시 참조)
출력값이 예상보다 항상 1 큰 이유는, sys.getrefcount(a)를 호출하는 순간 함수 인자로 a를 넘기면서 임시 참조가 하나 더 생기기 때문이다. 따라서 실제 ob_refcnt는 출력값에서 1을 빼면 된다. 이 임시 참조는 함수 호출이 끝나면 즉시 사라지므로 누적되지 않는다.
CPython 3.12부터 immortal objects 개념이 도입되어
None,True, 작은 정수 같은 객체는 refcount가 변하지 않는다.
참고로 del을 명시적으로 호출하지 않아도 메모리는 자동으로 해제된다. 함수가 끝나는 순간 지역 변수가 스코프에서 사라지면서 ob_refcnt가 0이 되고 즉시 반환된다. del은 함수가 아직 끝나지 않았지만 더 이상 필요 없는 대용량 데이터를 미리 해제하고 싶을 때 사용한다. 이 "즉시 해제" 방식은 해제 시점을 예측할 수 없는 Go의 GC와 대비되는 레퍼런스 카운팅의 장점이지만 그 대신 이 카운터를 보호하기 위해 GIL이라는 대가를 치르고 있는 것이다.
GIL이 없다면 어떻게 되는가
이제 핵심 질문에 도달한다. 왜 이 ob_refcnt에 GIL이 필요한가?
ob_refcnt -= 1은 코드상으로는 한 줄이지만, CPU 레벨에서는 세 단계를 거친다. 메모리에서 현재 값을 읽고(read), 1을 빼고(modify), 결과를 다시 메모리에 쓴다(write). 이 read-modify-write는 원자적(atomic) 연산이 아니다. 세 단계 사이 어디서든 다른 스레드가 끼어들 수 있다는 뜻이다.
GIL이 없는 상태에서 두 스레드가 동시에 같은 객체의 참조를 해제하는 상황을 생각해 보자.
markdownThread A: ob_refcnt 읽음 (=1) ← 컨텍스트 스위치 Thread B: ob_refcnt 읽음 (=1) → 1-1=0 저장 → 메모리 해제! Thread A: 1-1=0 저장 → 이미 해제된 메모리를 또 해제 → 크래시
Thread A가 ob_refcnt를 읽은 직후 Thread B가 끼어들어 같은 값을 읽고, 0으로 만들어 메모리를 해제해 버린다. 그 후 Thread A가 돌아와서 이미 해제된 메모리에 대해 다시 해제를 시도한다. 이는 double free로 인터프리터 크래시(segmentation fault)로 이어진다.
반대 방향의 문제도 존재한다. 두 스레드가 동시에 ob_refcnt를 증가시키려 할 때, 한쪽의 증가가 누락되면 실제로는 두 군데에서 참조하고 있는데 카운터는 1로 남는다. 이후 한쪽이 참조를 해제하면 카운터가 0이 되어 아직 사용 중인 객체가 해제된다. 이것은 use-after-free다. 반대로 감소가 누락되면 카운터가 영원히 0에 도달하지 못해 메모리 누수가 발생한다.
| Non GIL 시나리오 | 결과 |
|---|---|
| 감소가 중복 반영 | double free → 크래시 |
| 증가가 누락 | use-after-free → 크래시 또는 데이터 오염 |
| 감소가 누락 | memory leak → 메모리 고갈 |
GIL은 이 read-modify-write 과정 전체가 중간에 끊기지 않도록 보호한다. 인터프리터 내부의 ob_refcnt라는 공유 상태를 안전하게 유지하는 것이 GIL의 본질적인 존재 이유다.
GIL을 제거할 수는 없는가 — Free Threading 패치의 교훈
"그러면 GIL 대신 ob_refcnt 연산만 atomic으로 바꾸면 되지 않나?"라는 의문이 자연스럽게 든다. 실제로 이 시도는 이미 1990년대에 있었다.
파이썬의 창시자 귀도 반 로섬은 GIL 제거의 조건으로 명확한 기준을 제시한 바 있다.
"싱글 스레드 프로그램의 성능을 떨어뜨리지 않으면서 GIL을 제거할 수 있어야 한다."
1996년, Greg Stein이 Python 1.4를 대상으로 작성한 "Free Threading" 패치가 바로 그 시도였다. 이 패치는 GIL을 제거하고 모든 ob_refcnt 업데이트를 atomic 연산과 fine-grained lock으로 대체했다. 멀티스레드 성능은 확실히 향상되었지만, 싱글 스레드 성능이 약 2배 가까이 느려졌다. 귀도가 제시한 기준을 통과하지 못한 것이다. 약 20년 뒤인 2016년에는 Larry Hastings가 Gilectomy라는 이름으로 다시 한번 GIL 제거를 시도했지만, 이 역시 싱글 스레드 성능 저하를 극복하지 못했다.
왜 이렇게까지 느려졌을까? 일반적인 ob_refcnt++는 단순히 메모리 값을 1 증가시키는 동작이지만, atomic 연산은 CPU 차원에서 훨씬 복잡한 과정을 거친다.
- Bus Locking: 다른 코어가 해당 메모리 주소에 접근하지 못하도록 버스를 잠금
- Cache Invalidation: 다른 코어의 캐시에 저장된 해당 값을 무효화
- Pipeline Flush: CPU의 최적화 기법인 파이프라이닝이 중단
파이썬에서는 변수 할당, 함수 호출, 리스트 접근 등 거의 모든 동작에서 ob_refcnt가 변경된다. 루프 한 번 돌 때마다 수만 번의 atomic 연산이 발생하고, 그때마다 위의 고비용 작업이 수행되는 셈이다. 멀티스레드를 쓰지도 않는 프로그램이 단순히 "안전하게 숫자를 올리기 위해" 매번 이런 대가를 치르는 것은 받아들이기 어려운 트레이드오프였다. GIL은 이 현실에서 "싱글 스레드 성능을 지키는 쪽"을 택한 결과다.
3. GIL이 있는데도 왜 동시성 이슈가 생기나?
앞선 내용을 읽고 나면 GIL만으로 동시성 문제를 해결할 수 있나 생각이 들지만 실제로 파이썬 멀티스레드 코드를 작성해봤다면 생각보다 까다롭다는 것을 알 수 있다.
CPython은 GIL을 일정 시간 간격마다 현재 스레드에서 해제하고 다른 스레드로 넘기기 때문에 GIL이 있어도 스레드 간 컨텍스트 스위치는 계속 발생한다.
문제는 우리가 작성하는 파이썬 코드 한 줄도 실제로는 여러 개의 바이트코드 명령어로 분해된다는 점이다. 예를 들어 counter += 1이라는 단순한 한 줄도 내부적으로는 이렇게 동작한다.
pythonLOAD counter # 현재 값 읽기 LOAD 1 # 상수 1 로드 ADD # 더하기 STORE counter # 결과 저장
이 네 단계 사이 어디서든 컨텍스트 스위치가 발생할 수 있고, 두 스레드가 동시에 counter += 1을 실행한다고 가정해 보자.
pythonThread A: counter 읽음 (=0) ← 컨텍스트 스위치 Thread B: counter 읽음 (=0) → 계산 → counter = 1 저장 Thread A: 계산 → counter = 1 저장 ← 덮어씀! 기대값: 2 / 실제값: 1
Thread A가 counter를 읽은 직후 GIL이 Thread B로 넘어가고 Thread B가 같은 값을 읽어 1로 저장한 뒤 다시 Thread A가 돌아와 역시 1로 저장한다. 두 번 증가시켰지만 결과는 1이다. 전형적인 race condition이다.
이처럼 GIL은 "인터프리터가 죽지 않게" 하는 장치이지, "개발자의 공유 변수를 보호해주는" 장치가 아니다. 좀 더 정확하게 말하면, GIL은 객체의 "존재" 를 보호하지 객체의 "내용" 을 보호하지 않는다.
pythonshared_list = [] # Thread A shared_list.append(1) # 리스트 내용 수정 → GIL이 보호하지 않음 # Thread B del shared_list # ob_refcnt 감소 → GIL이 보호함
GIL 덕분에 shared_list 객체가 메모리에서 갑자기 삭제될 일은 없지만 객체 내부의 데이터가 여러 스레드에 의해 동시에 수정되는 것은 막지 못한다.
공유 자원에 대한 동시 접근을 막으려면 여전히 threading.Lock()을 직접 사용해야 한다.
결국 GIL과 Lock이라는 메커니즘은 동일하지만 보호 대상이 다르다. 코드에서 사용하는 threading.Lock()은 특정 공유 변수를 보호하기 위해 개발자가 직접 지정하는 것이고, GIL은 ob_refcnt 같은 인터프리터 내부 값을 보호하기 위해 인터프리터 전역에 고정된 것이다. GIL 이름 자체가 "전역 인터프리터 Lock"인 이유가 바로 여기에 있다.
4. GIL은 어떻게 "파이썬은 느리다"는 오명을 만들었는가?
개발자들 사이에서 흔히 도는 파이썬이 느리다는 오해는 이 GIL로부터 시작한다. 물론 인터프리터 언어 특성상 C나 Go 같은 컴파일 언어보다 느린 것은 사실이지만 그 오명의 상당 부분은 GIL로부터 비롯된다.
GIL은 CPython 인터프리터가 한 번에 하나의 스레드만 파이썬 바이트코드를 실행하도록 제어하는 잠금 장치다. CPU 코어가 8개, 16개인 최신 컴퓨터를 쓰고 있더라도 어느 순간에도 파이썬 바이트코드를 실행하는 스레드는 하나뿐이다. 8차선 도로에 검문소가 하나뿐이라 차가 한 대씩만 지나갈 수 있는 상황과 같다.
멀티코어를 활용할 수 없다는 것
이 제약이 가장 치명적으로 드러나는 건 CPU-bound 작업이다. 데이터 분석, 이미지 처리, 복잡한 수학 연산처럼 CPU를 집중적으로 사용하는 작업에서 일반적인 언어라면 4개의 스레드를 만들어 4개의 코어가 동시에 일하게 할 수 있다. 우리가 흔히 배운 동시성의 개념이다.
하지만 파이썬은 4개의 스레드를 만들어도 GIL 때문에 어느 순간에도 바이트코드를 실행하는 스레드는 하나뿐이다. 오히려 스레드를 전환하는 비용, 즉 Context Switching 오버헤드 때문에 싱글 스레드보다 더 느려지는 기현상이 발생하기도 한다. 파이썬의 threading 모듈을 사용한 멀티스레딩은 실제로 병렬 처리가 아닌 아주 빠르게 스레드를 번갈아 가며 실행하는 동시성 처리라고 부르는 것이 더 정확하다.
오명을 벗기 위한 노력들
파이썬 커뮤니티도 이 한계를 잘 알고 있으며 여러 방향에서 돌파구를 찾아왔다.
가장 대표적인 우회 전략은 Multiprocessing이다. 스레드 대신 프로세스를 여러 개 띄워 GIL의 제약을 완전히 피한다. 각 프로세스는 독립적인 GIL을 가지므로 진정한 병렬 실행이 가능하다. 메모리를 더 많이 소비한다는 단점이 있지만, CPU-bound 작업에서는 확실한 선택지다.
한편, 웹 크롤링이나 파일 읽기/쓰기, 데이터베이스 쿼리 같은 I/O-bound 작업에서는 얘기가 다르다. GIL의 영향이 크지 않은데 이는 CPython이 I/O 대기 상태에 진입하는 순간 GIL을 자동으로 해제하기 때문이다. 예를 들어 스레드 A가 네트워크 응답을 기다리는 동안 GIL을 release하면 스레드 B가 즉시 GIL을 획득하여 자신의 바이트코드를 실행할 수 있다.(하나의 프로세스 내에서 여러 스레드가 동일한 GIL을 공유하는 셈) CPU가 실제로 연산하는 시간보다 외부 응답을 기다리는 시간이 압도적으로 긴 I/O-bound 작업에서는 GIL acquire-release 메커니즘 덕분에 멀티스레딩이 실질적인 효과를 발휘한다.
여기에 asyncio를 활용하면 한 단계 더 나아갈 수 있다. asyncio는 멀티스레드가 아닌 단일 스레드 위에서 이벤트 루프를 돌리며 여러 I/O 작업을 동시에 관리하는 방식으로, GIL 경합 자체가 발생하지 않으므로 컨텍스트 스위칭 오버헤드 없이 수천 개의 동시 연결을 효율적으로 처리할 수 있다.
그리고 가장 주목할 만한 변화는 Python 3.13부터 도입되기 시작한 Free-threaded Python No-GIL 옵션이다. GIL을 선택적으로 비활성화할 수 있는 빌드가 실험적으로 제공되었고, 3.14에서는 공식 지원 단계로 올라가며 수십 년간 이어온 GIL의 족쇄를 서서히 풀어가고 있다.
CPython 3.13부터 시작된 Free-threaded 모드는 GIL 없이 파이썬을 실행할 수 있게 해주며, 3.14에서는 실험 단계를 벗어나 공식 지원(supported but not default)으로 격상되었다. 멀티스레드 병렬 실행 성능이 향상되지만, 아직 많은 C 확장 모듈과의 호환성 문제가 남아 있다.
5. 왜 Python에만 GIL이 존재하는가?
그럼 왜 파이썬에만 이런 개념이 존재하는 걸까? Go에서는 GIL 같은 게 없어도 멀티스레드 프로그램이 잘 동작하지 않는가?
핵심은 메모리 관리 방식의 차이다.
CPython은 메모리 해제를 레퍼런스 카운팅으로 수행한다. 객체가 생성되거나 삭제될 때마다 ob_refcnt를 읽고 쓰는데 멀티스레드 환경에서는 여러 스레드가 이 값을 동시에 수정할 위험이 생긴다. 파이썬은 이 문제에 대해 가장 단순한 해결책을 택했다.
인터프리터 전체에 하나의 Lock을 거는 것, 즉 GIL이다.
반면 Go는 GC(Garbage Collector) 방식으로 메모리를 관리한다. 레퍼런스 카운트 같은 공유 카운터가 애초에 존재하지 않는다. GC가 별도의 과정을 통해 주기적으로 "누가 이 메모리를 쓰고 있는가"를 추적하고, 더 이상 참조되지 않는 객체를 정리한다. 스레드들이 공유 카운터를 건드릴 일 자체가 없으니, GIL이라는 개념이 필요하지 않은 것이다.
다른 언어들도 마찬가지다. Java 역시 GC 기반이라 GIL이 없고, Rust는 한 발 더 나아가 컴파일 타임에 소유권 검사를 수행하므로 GC조차 필요 없다. 다만 Rust도 Arc(Atomic Reference Counting) 같은 스마트 포인터를 통해 필요할 때만 atomic refcounting 비용을 치르는데, 이는 파이썬이 모든 객체에 대해 매번 그 비용을 감수해야 하는 것과 대비된다. C/C++은 메모리 관리를 개발자에게 맡기는데, 그 대가로 잘못된 접근 시 크래시가 발생할 수 있다.
| 언어 | 메모리 관리 | GIL |
|---|---|---|
| Python | 레퍼런스 카운팅 | 있음 |
| Go | GC | 없음 |
| Java | GC | 없음 |
| Rust | 컴파일 타임 소유권 검사 | 없음 (GC도 없음) |
| C/C++ | 개발자가 직접 관리 | 없음 (대신 크래시 위험) |
Q. 그럼 Go에서는 멀티스레딩을 어떻게 구현하는가?
파이썬과 달리 Go는 실제로 여러 코어를 동시에 활용할 수 있다. 그 핵심에는 고루틴(Goroutine) 이라는 개념이 있다.
Go에서는 스레드라는 용어 대신 고루틴을 사용한다. 가장 큰 차이는 무게다. 파이썬이 사용하는 OS 스레드는 기본 스택 크기가 수 MB로 할당되지만, 고루틴은 하나당 단 2KB 정도로 시작한다. 파이썬에서 스레드 수천 개를 만들면 시스템에 심각한 부하가 걸리지만, Go에서는 고루틴 수십만 개를 띄워도 문제없이 동작한다.
더 중요한 건 Go 런타임이 가용한 모든 CPU 코어를 자동으로 활용한다는 점이다. 8코어 CPU라면 물리적으로 8개의 작업을 동시에, 즉 진정한 의미의 병렬(Parallel)로 수행할 수 있다.
Go가 이를 가능하게 하는 비결은 M:N 스케줄링 모델에 있다. 다수의 고루틴(G)을 논리적 프로세서(P)가 관리하고, 이를 실제 OS 스레드(M)에 매핑하는 구조다. 특정 코어에서 실행 중인 고루틴이 I/O 대기 등으로 멈추면, Go 스케줄러는 즉시 다른 고루틴을 해당 코어에 할당한다. 여기에 Work Stealing이라는 메커니즘도 더해진다. 놀고 있는 코어가 있으면 바쁜 코어의 작업 큐에서 일을 가져와 처리하므로, 모든 CPU 코어가 유휴 상태 없이 풀가동된다.
코드 레벨에서 보면 그 차이는 더 극명하다.
python# Python: threading을 써도 GIL 때문에 바이트코드를 실행하는 스레드는 항상 하나 t1 = threading.Thread(target=task) t1.start()
go// Go: 'go' 키워드 하나로 여러 코어에 분산되어 병렬 실행 go task()
파이썬은 threading.Thread를 만들어도 GIL 아래에서 바이트코드를 실행하는 스레드는 항상 하나지만, Go는 go 키워드 하나로 런타임이 알아서 여러 코어에 작업을 분배한다. GIL이 없기 때문에 가능한 일이다.
왜 파이썬은 애초에 레퍼런스 카운팅을 선택했을까?
1991년 파이썬이 만들어질 당시, GC는 구현이 복잡하고 무거운 기술이었다. 레퍼런스 카운팅은 그에 비해 구현이 단순하고, 메모리 해제 시점을 예측할 수 있으며, 다양한 플랫폼에 이식하기 쉽다는 명확한 장점이 있었다. 싱글 스레드 시대에는 완벽한 선택이었다.
그러나 멀티코어 시대가 도래하면서 이 선택은 한계로 돌아왔다. 레퍼런스 카운팅의 동시 수정 문제를 해결하기 위해 GIL이 추가되었고, GIL 때문에 멀티코어를 제대로 활용하지 못하는 구조가 굳어진 것이다. 결국 GIL은 파이썬이 레퍼런스 카운팅이라는 메모리 관리 전략을 선택한 데서 비롯된 필연적인 결과다.
6. GIL이 사라진 파이썬은 어떻게 달라지는가
앞서 PEP 703과 Free-threaded Python이 GIL 제거를 현실화했듯 GIL이 사라진 파이썬에서 CPU-bound 코드는 어떻게 달라질까?
threading이 진짜 병렬화된다
가장 큰 변화는 threading만으로도 멀티코어를 온전히 활용할 수 있게 된다는 점이다. 이전에는 코어가 16개라도 CPU-bound 작업에서 스레드들이 GIL을 획득하기 위해 대기해야 했다. GIL이 사라지면 16개의 스레드가 16개의 코어 위에서 동시에 실행될 수 있다.
이는 코드 작성 패턴에도 직접적인 영향을 준다.
그동안 파이썬에서 CPU 연산을 병렬화하려면 multiprocessing을 사용해야 했다. 프로세스를 새로 띄우는 방식은 메모리 소비가 크고, 프로세스 간 데이터를 주고받는 직렬화(Serialization) 비용도 상당했다. GIL이 없어지면 threading 모듈만으로도 CPU-bound 병렬 실행이 가능해진다. 메모리를 공유한 채 병렬 실행이 가능하기에 대규모 데이터 작업의 효율 역시 크게 높아진다.
물론 프로세스 격리가 필요한, 예를 들어 워커 장애 격리나 메모리 누수 방지가 중요한 환경에서는 multiprocessing이 여전히 유효한 선택이다.
python# GIL이 있던 시절: CPU-bound 병렬화에 multiprocessing 필수 from multiprocessing import Pool with Pool(4) as p: results = p.map(heavy_computation, data) # Free-threaded Python: threading으로도 진정한 병렬 실행 가능 from threading import Thread threads = [Thread(target=heavy_computation, args=(chunk,)) for chunk in data] for t in threads: t.start()
Thread Safety의 책임이 개발자에게 온다
GIL이 사라지면 개발자의 책임은 늘어난다. GIL은 그동안 "적어도 한 번에 한 스레드만 바이트코드를 실행한다"는 최소한의 안전장치 역할을 해왔다. 그 결과 리스트나 딕셔너리 조작 중 일부는 사실상 원자적으로 동작했고, 명시적인 Lock 없이도 문제가 드러나지 않는 경우가 많았다.
3장에서 GIL이 공유 변수를 보호하지 않는다고 했지만, 그것은 counter += 1처럼 여러 바이트코드로 쪼개지는 연산에 한정된 이야기였다. 반대로 list.append()처럼 단일 바이트코드로 처리되는 연산은 GIL 덕분에 사실상 원자적이었고, 이 암묵적 보호가 Free-threaded 환경에서는 사라진다.
GIL이 없어지면 두 스레드가 동시에 같은 리스트에 접근하여 데이터를 오염시킬 위험이 현실이 된다. threading.Lock()을 정교하게 사용해야 하며, 공유 자원에 대한 동시 접근을 명시적으로 제어하는 것이 필수가 된다.
생태계의 과도기
가장 큰 과제는 라이브러리 호환성이다. 수만 개의 C 확장 모듈이 "GIL이 있다"는 전제 위에 설계되어 있다. GIL이 사라지면 이 라이브러리들이 멀티스레드 환경에서 예상치 못한 race condition을 일으킬 수 있다. 당분간은 사용하는 라이브러리가 Free-threaded 모드와 호환되는지 확인하는 과정이 필요하다.
또한 GIL 제거의 대가로 싱글 스레드 성능이 하락할 수 있다. 앞서 다룬 Biased Reference Counting 등의 기법으로 최소화했지만, 초기 벤치마크 기준 약 5~10% 수준의 성능 저하가 보고되었다. 이것이 Free-threaded 빌드가 기본값이 아닌 선택 옵션으로 제공되는 이유이기도 하다.
마무리
GIL은 파이썬의 결함이 아닌 싱글코어 시대에 내려진 합리적인 설계 결정이었다. 역설적으로 GIL이 있었기에 C 확장 모듈 개발자들이 스레드 안전성을 신경 쓰지 않고도 라이브러리를 작성할 수 있었고 그것이 오늘날 파이썬 생태계의 토대가 되었다.
멀티코어 시대에 GIL의 한계를 느끼고 있다면 지금 취할 수 있는 선택지는 명확하다. CPU-bound 작업에는 multiprocessing으로 프로세스를 분리하고, I/O-bound 작업은 asyncio로 단일 스레드에서 효율적으로 처리하며, 진정한 병렬 실행이 필요하다면 Python 3.13+ 의 Free-threaded 빌드를 실험해 볼 수 있다.
멀티코어 시대가 도래하면서 기존의 GIL 설계는 한계를 드러냈지만 GIL을 제거하는 것은 단순한 기술적 문제가 아니라 수십 년간 쌓아올린 생태계 전체의 전제를 뒤흔드는 일이었다.
파이썬 커뮤니티는 그 무게를 알면서도 PEP 703과 Free-threaded Python을 통해 마침내 그 굴레를 풀어가고 있다.